winbrew_app\operations\doctor\scan/
journal.rs

1use std::collections::HashMap;
2use std::io::ErrorKind;
3use std::path::Path;
4
5use crate::core::paths::ResolvedPaths;
6use crate::database;
7use crate::models::domains::installed::InstalledPackage;
8use crate::models::domains::reporting::{DiagnosisResult, DiagnosisSeverity};
9use crate::models::domains::shared::DeploymentKind;
10use tracing::debug;
11
12use super::{PackageJournalScan, ScanResult, sort_diagnoses, sort_recovery_findings};
13
14mod error_codes {
15    pub const PKGDB_UNREADABLE: &str = "pkgdb_unreadable";
16    pub const INCOMPLETE_JOURNAL: &str = "incomplete_package_journal";
17    pub const UNREADABLE_JOURNAL: &str = "unreadable_package_journal";
18    pub const MALFORMED_JOURNAL: &str = "malformed_package_journal";
19    pub const TRAILING_JOURNAL: &str = "trailing_package_journal";
20    pub const MISSING_METADATA: &str = "missing_journal_metadata";
21    pub const ORPHAN_JOURNAL: &str = "orphan_package_journal";
22    pub const STALE_JOURNAL: &str = "stale_package_journal";
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub(super) struct JournalMetadata<'a> {
27    pub(super) package_id: &'a str,
28    pub(super) version: &'a str,
29    pub(super) engine: &'a str,
30    pub(super) deployment_kind: DeploymentKind,
31    pub(super) install_dir: &'a str,
32}
33
34/// Create a standardized diagnosis result with consistent formatting.
35///
36/// This is the base builder for all diagnosis results in the journal scanner.
37/// Use [`journal_error`] for journal-specific errors with path prefixing.
38///
39/// # Arguments
40/// * `error_code` - Machine-readable error identifier.
41/// * `description` - Human-readable error description.
42/// * `severity` - Error severity level.
43#[inline]
44fn diagnosis(
45    error_code: &str,
46    description: String,
47    severity: DiagnosisSeverity,
48) -> DiagnosisResult {
49    DiagnosisResult {
50        error_code: error_code.to_string(),
51        description,
52        severity,
53    }
54}
55
56/// Create a journal-specific diagnosis with automatic path prefixing.
57///
58/// Formats the description as `{path}: {message}` for consistent error
59/// messages.
60///
61/// # Example
62/// ```ignore
63/// let diag = journal_error(
64///     &path,
65///     "incomplete_journal",
66///     "incomplete recovery journal",
67///     DiagnosisSeverity::Error,
68/// );
69/// ```
70#[inline]
71fn journal_error(
72    journal_path: &Path,
73    error_code: &str,
74    message: impl std::fmt::Display,
75    severity: DiagnosisSeverity,
76) -> DiagnosisResult {
77    diagnosis(
78        error_code,
79        format!("{}: {}", journal_path.to_string_lossy(), message),
80        severity,
81    )
82}
83
84/// Convert a [`database::JournalReadError`] into a structured diagnosis result.
85///
86/// Maps all journal reading error variants to appropriate diagnosis codes and
87/// severity levels. Returns the diagnosis along with an optional path
88/// reference for recovery findings.
89///
90/// # Error Mapping
91/// - `Incomplete` -> error, no path reference
92/// - `Read` -> error, no path reference
93/// - `MalformedLine` -> error with line number, no path reference
94/// - `TrailingEntries` -> error with line number, with path reference
95fn journal_read_error_diagnosis(
96    journal_path: &Path,
97    error: database::JournalReadError,
98) -> (DiagnosisResult, Option<&Path>) {
99    match error {
100        database::JournalReadError::Incomplete { .. } => (
101            journal_error(
102                journal_path,
103                error_codes::INCOMPLETE_JOURNAL,
104                "incomplete recovery journal",
105                DiagnosisSeverity::Error,
106            ),
107            None,
108        ),
109        database::JournalReadError::Read { .. } => (
110            journal_error(
111                journal_path,
112                error_codes::UNREADABLE_JOURNAL,
113                "recovery journal is unreadable",
114                DiagnosisSeverity::Error,
115            ),
116            None,
117        ),
118        database::JournalReadError::MalformedLine { line, .. } => (
119            journal_error(
120                journal_path,
121                error_codes::MALFORMED_JOURNAL,
122                format!("recovery journal has malformed line {line}"),
123                DiagnosisSeverity::Error,
124            ),
125            None,
126        ),
127        database::JournalReadError::TrailingEntries { line, .. } => (
128            journal_error(
129                journal_path,
130                error_codes::TRAILING_JOURNAL,
131                format!("recovery journal has trailing entries after commit on line {line}"),
132                DiagnosisSeverity::Error,
133            ),
134            Some(journal_path),
135        ),
136    }
137}
138
139pub(super) fn extract_journal_metadata(
140    entries: &[database::JournalEntry],
141) -> Option<JournalMetadata<'_>> {
142    entries.iter().find_map(|entry| match entry {
143        database::JournalEntry::Metadata {
144            package_id,
145            version,
146            engine,
147            deployment_kind,
148            install_dir,
149            dependencies: _,
150            commands: _,
151            bin: _,
152            bin_bindings: _,
153            env_add_path: _,
154            command_resolution: _,
155            engine_metadata: _,
156        } => Some(JournalMetadata {
157            package_id: package_id.as_str(),
158            version: version.as_str(),
159            engine: engine.as_str(),
160            deployment_kind: *deployment_kind,
161            install_dir: install_dir.as_str(),
162        }),
163        _ => None,
164    })
165}
166
167pub(super) fn journal_metadata_matches_package(
168    package: &InstalledPackage,
169    metadata: &JournalMetadata<'_>,
170) -> bool {
171    package.version == metadata.version
172        && package
173            .engine_kind
174            .as_str()
175            .eq_ignore_ascii_case(metadata.engine)
176        && package.install_dir == metadata.install_dir
177        && package.deployment_kind == metadata.deployment_kind
178}
179
180fn process_journal_entry(
181    entry_path: &Path,
182    package_lookup: &HashMap<&str, &InstalledPackage>,
183    result: &mut PackageJournalScan,
184) {
185    let journal_path = entry_path.join("journal.jsonl");
186
187    match database::JournalReader::read_committed(&journal_path) {
188        Ok(entries) => {
189            for diagnosis in diagnose_committed_journal(&journal_path, &entries, package_lookup) {
190                result.push(diagnosis, Some(&journal_path));
191            }
192        }
193        Err(database::JournalReadError::Read { source, .. })
194            if source.kind() == ErrorKind::NotFound =>
195        {
196            debug!(path = %journal_path.display(), "missing journal file, skipping package directory");
197        }
198        Err(error) => {
199            let (diagnosis, target_path) = journal_read_error_diagnosis(&journal_path, error);
200            result.push(diagnosis, target_path);
201        }
202    }
203}
204
205/// Scan package journal files under `data/pkgdb` and report recovery issues.
206pub(super) fn scan_package_journals(
207    paths: &ResolvedPaths,
208    packages: &[InstalledPackage],
209) -> PackageJournalScan {
210    let pkgdb_root = &paths.pkgdb;
211
212    if !pkgdb_root.exists() {
213        debug!(path = %pkgdb_root.display(), "pkgdb root does not exist, skipping journal scan");
214        return ScanResult::default();
215    }
216
217    let package_lookup: HashMap<&str, &InstalledPackage> = packages
218        .iter()
219        .map(|package| (package.name.as_str(), package))
220        .collect();
221
222    let entries = match std::fs::read_dir(pkgdb_root) {
223        Ok(entries) => entries,
224        Err(err) => {
225            if err.kind() == std::io::ErrorKind::NotFound {
226                debug!(path = %pkgdb_root.display(), "pkgdb root disappeared before journal scan");
227                return ScanResult::default();
228            }
229
230            let mut result = ScanResult::default();
231            result.push(
232                diagnosis(
233                    error_codes::PKGDB_UNREADABLE,
234                    format!(
235                        "pkgdb root: unreadable journal directory ({}) - {err}",
236                        pkgdb_root.to_string_lossy()
237                    ),
238                    DiagnosisSeverity::Error,
239                ),
240                None,
241            );
242            return result;
243        }
244    };
245
246    let mut result = ScanResult::default();
247
248    for entry_result in entries {
249        let entry = match entry_result {
250            Ok(entry) => entry,
251            Err(err) => {
252                debug!(path = %pkgdb_root.display(), error = %err, "skipping unreadable pkgdb entry");
253                continue;
254            }
255        };
256
257        let entry_path = entry.path();
258
259        let file_type = match entry.file_type() {
260            Ok(file_type) => file_type,
261            Err(err) => {
262                debug!(path = %entry_path.display(), error = %err, "skipping pkgdb entry with unreadable file type");
263                continue;
264            }
265        };
266
267        if !file_type.is_dir() {
268            continue;
269        }
270
271        process_journal_entry(&entry_path, &package_lookup, &mut result);
272    }
273
274    sort_diagnoses(&mut result.diagnostics);
275    sort_recovery_findings(&mut result.recovery_findings);
276
277    result
278}
279
280fn diagnose_committed_journal(
281    journal_path: &Path,
282    entries: &[database::JournalEntry],
283    packages: &HashMap<&str, &InstalledPackage>,
284) -> Vec<DiagnosisResult> {
285    let Some(metadata) = extract_journal_metadata(entries) else {
286        return vec![journal_error(
287            journal_path,
288            error_codes::MISSING_METADATA,
289            "committed recovery journal is missing metadata",
290            DiagnosisSeverity::Error,
291        )];
292    };
293
294    diagnose_committed_journal_metadata(journal_path, &metadata, packages)
295        .map(|diagnosis| vec![diagnosis])
296        .unwrap_or_default()
297}
298
299fn diagnose_committed_journal_metadata(
300    journal_path: &Path,
301    metadata: &JournalMetadata<'_>,
302    packages: &HashMap<&str, &InstalledPackage>,
303) -> Option<DiagnosisResult> {
304    let Some(package) = packages.get(metadata.package_id) else {
305        return Some(journal_error(
306            journal_path,
307            error_codes::ORPHAN_JOURNAL,
308            "committed recovery journal has no installed package",
309            DiagnosisSeverity::Warning,
310        ));
311    };
312
313    if !journal_metadata_matches_package(package, metadata) {
314        return Some(journal_error(
315            journal_path,
316            error_codes::STALE_JOURNAL,
317            format!(
318                "recovery journal does not match installed package {} ({})",
319                package.name, package.version
320            ),
321            DiagnosisSeverity::Warning,
322        ));
323    }
324
325    None
326}
327
328#[cfg(test)]
329mod tests {
330    use super::{
331        JournalMetadata, diagnose_committed_journal_metadata, extract_journal_metadata,
332        journal_metadata_matches_package, scan_package_journals,
333    };
334    use crate::core::paths::{ResolvedPaths, resolved_paths};
335    use crate::database;
336    use crate::models::domains::install::{EngineKind, InstallerType};
337    use crate::models::domains::installed::{InstalledPackage, PackageStatus};
338    use crate::models::domains::reporting::{
339        DiagnosisResult, DiagnosisSeverity, RecoveryActionGroup, RecoveryFinding, RecoveryIssueKind,
340    };
341    use crate::models::domains::shared::DeploymentKind;
342    use std::fs;
343    use std::path::{Path, PathBuf};
344    use tempfile::{TempDir, tempdir};
345
346    struct TestEnvironment {
347        _root: TempDir,
348        paths: ResolvedPaths,
349    }
350
351    impl TestEnvironment {
352        fn new() -> Self {
353            let root = tempdir().expect("temp dir should be created");
354            let paths = Self::build_paths(root.path());
355
356            Self { _root: root, paths }
357        }
358
359        fn build_paths(root: &Path) -> ResolvedPaths {
360            let packages = root.join("packages").to_string_lossy().into_owned();
361            let data = root.join("data").to_string_lossy().into_owned();
362            let logs = root.join("logs").to_string_lossy().into_owned();
363            let cache = root.join("cache").to_string_lossy().into_owned();
364
365            resolved_paths(root, &packages, &data, &logs, &cache)
366        }
367
368        fn root(&self) -> &Path {
369            self._root.path()
370        }
371
372        fn pkgdb_root(&self) -> &Path {
373            &self.paths.pkgdb
374        }
375
376        fn create_dir(&self, path: &Path) {
377            fs::create_dir_all(path).expect("directory should be created");
378        }
379
380        fn write_file(&self, path: &Path, content: &[u8]) {
381            if let Some(parent) = path.parent() {
382                fs::create_dir_all(parent).expect("parent directory should be created");
383            }
384
385            fs::write(path, content).expect("file should be written");
386        }
387
388        fn journal_path(&self, package_name: &str) -> PathBuf {
389            self.pkgdb_root().join(package_name).join("journal.jsonl")
390        }
391    }
392
393    fn assert_single_diagnosis<'a>(
394        diagnostics: &'a [DiagnosisResult],
395        expected_error_code: &str,
396        expected_severity: DiagnosisSeverity,
397    ) -> &'a DiagnosisResult {
398        assert_eq!(diagnostics.len(), 1, "expected exactly one diagnosis");
399
400        let diagnosis = &diagnostics[0];
401        assert_eq!(diagnosis.error_code, expected_error_code);
402        assert_eq!(diagnosis.severity, expected_severity);
403
404        diagnosis
405    }
406
407    fn assert_single_recovery_finding(
408        findings: &[RecoveryFinding],
409        expected_issue_kind: RecoveryIssueKind,
410        expected_action_group: Option<RecoveryActionGroup>,
411    ) -> &RecoveryFinding {
412        assert_eq!(findings.len(), 1, "expected exactly one recovery finding");
413
414        let finding = &findings[0];
415        assert_eq!(finding.issue_kind, expected_issue_kind);
416        assert_eq!(finding.action_group, expected_action_group);
417
418        finding
419    }
420
421    fn assert_recovery_target_path(finding: &RecoveryFinding, expected_path: &Path) {
422        let expected_path = expected_path.to_string_lossy().to_string();
423        assert_eq!(finding.target_path.as_deref(), Some(expected_path.as_str()));
424    }
425
426    fn journal_metadata_entry(package_name: &str) -> database::JournalEntry {
427        database::JournalEntry::Metadata {
428            package_id: package_name.to_string(),
429            version: "1.0.0".to_string(),
430            engine: "msi".to_string(),
431            deployment_kind: DeploymentKind::Installed,
432            install_dir: format!(r"C:\winbrew\apps\{package_name}"),
433            dependencies: Vec::new(),
434            commands: None,
435            bin: None,
436            bin_bindings: None,
437            env_add_path: Vec::new(),
438            command_resolution: None,
439            engine_metadata: None,
440        }
441    }
442
443    fn journal_commit_entry() -> database::JournalEntry {
444        database::JournalEntry::Commit {
445            installed_at: "2026-04-12T00:00:00Z".to_string(),
446        }
447    }
448
449    fn write_journal(
450        env: &TestEnvironment,
451        package_name: &str,
452        build: impl FnOnce(&mut database::JournalWriter),
453    ) -> PathBuf {
454        let package_key = database::package_journal_key(package_name, "1.0.0");
455        fs::create_dir_all(env.root().join("data").join("pkgdb").join(&package_key))
456            .expect("journal package directory should be created");
457        let mut writer =
458            database::JournalWriter::open_for_package(env.root(), package_name, "1.0.0")
459                .expect("open journal");
460        build(&mut writer);
461        writer.flush().expect("flush journal");
462        writer.path().to_path_buf()
463    }
464
465    fn write_metadata_only_journal(env: &TestEnvironment, package_name: &str) {
466        let _ = write_journal(env, package_name, |writer| {
467            writer
468                .append(&journal_metadata_entry(package_name))
469                .expect("write metadata");
470        });
471    }
472
473    fn write_commit_only_journal(env: &TestEnvironment, package_name: &str) -> PathBuf {
474        write_journal(env, package_name, |writer| {
475            writer
476                .append(&journal_commit_entry())
477                .expect("write commit");
478        })
479    }
480
481    fn write_committed_journal(env: &TestEnvironment, package_name: &str) -> PathBuf {
482        write_journal(env, package_name, |writer| {
483            writer
484                .append(&journal_metadata_entry(package_name))
485                .expect("write metadata");
486            writer
487                .append(&journal_commit_entry())
488                .expect("write commit");
489        })
490    }
491
492    fn write_committed_journal_with_trailing_entry(
493        env: &TestEnvironment,
494        package_name: &str,
495        trailing_path: &str,
496    ) -> PathBuf {
497        write_journal(env, package_name, |writer| {
498            writer
499                .append(&journal_metadata_entry(package_name))
500                .expect("write metadata");
501            writer
502                .append(&journal_commit_entry())
503                .expect("write commit");
504            writer
505                .append(&database::JournalEntry::FsCreate {
506                    path: trailing_path.to_string(),
507                    hash: None,
508                })
509                .expect("write trailing entry");
510        })
511    }
512
513    fn sample_package() -> InstalledPackage {
514        InstalledPackage {
515            name: "Contoso.App".to_string(),
516            version: "1.0.0".to_string(),
517            kind: InstallerType::Msi,
518            deployment_kind: DeploymentKind::Installed,
519            engine_kind: EngineKind::Msi,
520            engine_metadata: None,
521            install_dir: r"C:\winbrew\apps\Contoso.App".to_string(),
522            dependencies: Vec::new(),
523            status: PackageStatus::Ok,
524            installed_at: "2026-04-12T00:00:00Z".to_string(),
525        }
526    }
527
528    #[test]
529    fn extract_journal_metadata_returns_structured_metadata() {
530        let entries = vec![
531            crate::database::JournalEntry::FsCreate {
532                path: r"C:\winbrew\apps\Contoso.App\bin\tool.exe".to_string(),
533                hash: None,
534            },
535            journal_metadata_entry("Contoso.App"),
536            journal_commit_entry(),
537        ];
538
539        let metadata = extract_journal_metadata(&entries).expect("metadata should be found");
540
541        assert_eq!(metadata.package_id, "Contoso.App");
542        assert_eq!(metadata.version, "1.0.0");
543        assert_eq!(metadata.engine, "msi");
544        assert_eq!(metadata.deployment_kind, DeploymentKind::Installed);
545        assert_eq!(metadata.install_dir, r"C:\winbrew\apps\Contoso.App");
546    }
547
548    #[test]
549    fn extract_journal_metadata_returns_none_when_no_metadata_entry() {
550        let entries = vec![journal_commit_entry()];
551
552        assert!(extract_journal_metadata(&entries).is_none());
553    }
554
555    #[test]
556    fn journal_metadata_matches_package_accepts_matching_package_fields() {
557        let package = sample_package();
558        let metadata = JournalMetadata {
559            package_id: "Contoso.App",
560            version: "1.0.0",
561            engine: "msi",
562            deployment_kind: DeploymentKind::Installed,
563            install_dir: r"C:\winbrew\apps\Contoso.App",
564        };
565
566        assert!(journal_metadata_matches_package(&package, &metadata));
567    }
568
569    #[test]
570    fn journal_metadata_matches_package_engine_comparison_is_case_insensitive() {
571        let package = sample_package();
572        let metadata = JournalMetadata {
573            package_id: "Contoso.App",
574            version: "1.0.0",
575            engine: "MSI",
576            deployment_kind: DeploymentKind::Installed,
577            install_dir: r"C:\winbrew\apps\Contoso.App",
578        };
579
580        assert!(journal_metadata_matches_package(&package, &metadata));
581    }
582
583    #[test]
584    fn diagnose_committed_journal_metadata_returns_stale_diagnosis_for_changed_package() {
585        let package = sample_package();
586        let metadata = JournalMetadata {
587            package_id: "Contoso.App",
588            version: "0.9.0",
589            engine: "msi",
590            deployment_kind: DeploymentKind::Installed,
591            install_dir: r"C:\winbrew\apps\Contoso.App",
592        };
593        let packages = std::collections::HashMap::from([(package.name.as_str(), &package)]);
594
595        let diagnosis = diagnose_committed_journal_metadata(
596            &PathBuf::from(r"C:\winbrew\pkgdb\Contoso.App\journal.jsonl"),
597            &metadata,
598            &packages,
599        )
600        .expect("stale package should produce a diagnosis");
601
602        assert_eq!(diagnosis.error_code, "stale_package_journal");
603    }
604
605    #[test]
606    fn scan_package_journals_detects_stale_committed_journal() {
607        let env = TestEnvironment::new();
608        let journal_path = write_committed_journal(&env, "Contoso.Stale");
609
610        let mut package = sample_package();
611        package.name = "Contoso.Stale".to_string();
612        package.version = "2.0.0".to_string();
613        package.install_dir = r"C:\winbrew\apps\Contoso.Stale".to_string();
614
615        let scan = scan_package_journals(&env.paths, &[package]);
616
617        let diagnosis = assert_single_diagnosis(
618            &scan.diagnostics,
619            "stale_package_journal",
620            DiagnosisSeverity::Warning,
621        );
622        assert!(
623            diagnosis
624                .description
625                .contains("recovery journal does not match")
626        );
627
628        let finding = assert_single_recovery_finding(
629            &scan.recovery_findings,
630            RecoveryIssueKind::Conflict,
631            Some(RecoveryActionGroup::JournalReplay),
632        );
633        assert_recovery_target_path(finding, &journal_path);
634    }
635
636    #[test]
637    fn scan_package_journals_detects_incomplete_journal() {
638        let env = TestEnvironment::new();
639
640        write_metadata_only_journal(&env, "Contoso.Recover");
641
642        let scan = scan_package_journals(&env.paths, &[]);
643
644        assert_single_diagnosis(
645            &scan.diagnostics,
646            "incomplete_package_journal",
647            DiagnosisSeverity::Error,
648        );
649
650        let finding = assert_single_recovery_finding(
651            &scan.recovery_findings,
652            RecoveryIssueKind::RecoveryTrailMissing,
653            None,
654        );
655        assert!(finding.target_path.is_none());
656    }
657
658    #[test]
659    fn scan_package_journals_detects_malformed_journal() {
660        let env = TestEnvironment::new();
661
662        let journal_path = env.journal_path("Contoso.Malformed");
663        env.write_file(&journal_path, b"{not-json}\n");
664
665        let scan = scan_package_journals(&env.paths, &[]);
666
667        assert_single_diagnosis(
668            &scan.diagnostics,
669            "malformed_package_journal",
670            DiagnosisSeverity::Error,
671        );
672
673        let finding = assert_single_recovery_finding(
674            &scan.recovery_findings,
675            RecoveryIssueKind::RecoveryTrailMissing,
676            None,
677        );
678        assert!(finding.target_path.is_none());
679    }
680
681    #[test]
682    fn scan_package_journals_skips_package_directory_without_journal_file() {
683        let env = TestEnvironment::new();
684
685        let journal_dir = env.pkgdb_root().join("Contoso.MissingJournal");
686        env.create_dir(&journal_dir);
687
688        let scan = scan_package_journals(&env.paths, &[]);
689
690        assert!(scan.diagnostics.is_empty());
691        assert!(scan.recovery_findings.is_empty());
692    }
693
694    #[test]
695    fn scan_package_journals_reports_missing_journal_metadata() {
696        let env = TestEnvironment::new();
697
698        let journal_path = write_commit_only_journal(&env, "Contoso.MissingMeta");
699
700        let scan = scan_package_journals(&env.paths, &[]);
701
702        assert_single_diagnosis(
703            &scan.diagnostics,
704            "missing_journal_metadata",
705            DiagnosisSeverity::Error,
706        );
707
708        let finding = assert_single_recovery_finding(
709            &scan.recovery_findings,
710            RecoveryIssueKind::RecoveryTrailMissing,
711            None,
712        );
713        assert_recovery_target_path(finding, &journal_path);
714    }
715
716    #[test]
717    fn scan_package_journals_detects_orphan_committed_journal() {
718        let env = TestEnvironment::new();
719
720        let journal_path = write_committed_journal(&env, "Contoso.Orphan");
721
722        let scan = scan_package_journals(&env.paths, &[]);
723
724        let diagnosis = assert_single_diagnosis(
725            &scan.diagnostics,
726            "orphan_package_journal",
727            DiagnosisSeverity::Warning,
728        );
729        assert!(diagnosis.description.contains("no installed package"));
730
731        let finding = assert_single_recovery_finding(
732            &scan.recovery_findings,
733            RecoveryIssueKind::IncompleteInstall,
734            Some(RecoveryActionGroup::JournalReplay),
735        );
736        assert_recovery_target_path(finding, &journal_path);
737    }
738
739    #[test]
740    fn scan_package_journals_tracks_trailing_journal_replay_target() {
741        let env = TestEnvironment::new();
742
743        let journal_path = write_committed_journal_with_trailing_entry(
744            &env,
745            "Contoso.Trailing",
746            r"C:\winbrew\apps\Contoso.Trailing\payload.exe",
747        );
748
749        let scan = scan_package_journals(&env.paths, &[]);
750
751        let diagnosis = assert_single_diagnosis(
752            &scan.diagnostics,
753            "trailing_package_journal",
754            DiagnosisSeverity::Error,
755        );
756        assert!(
757            diagnosis
758                .description
759                .contains("trailing entries after commit")
760        );
761
762        let finding = assert_single_recovery_finding(
763            &scan.recovery_findings,
764            RecoveryIssueKind::Conflict,
765            Some(RecoveryActionGroup::JournalReplay),
766        );
767        assert_recovery_target_path(finding, &journal_path);
768    }
769}